Skip to content

feat(nix): migrate the devcontainer to a Nix toolchain + Claude-native setup (#625)#670

Open
c-vigo wants to merge 155 commits into
devfrom
feature/625-nix-claude-migration
Open

feat(nix): migrate the devcontainer to a Nix toolchain + Claude-native setup (#625)#670
c-vigo wants to merge 155 commits into
devfrom
feature/625-nix-claude-migration

Conversation

@c-vigo

@c-vigo c-vigo commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Lands epic #625: the devcontainer is now built and provisioned entirely by Nix, with a Claude-native agent setup. The Debian/apt + Cursor stack is gone.

What changed (by track)

  • Track C — Cursor → Claude: .claude/ is the SSoT for rules/skills; worktree pipelines drive claude; commit-msg blocklist still blocks naming any AI agent.
  • Track 1 — Flake as SSoT: flake.nix is the single toolchain source (devTools, lib.mkProjectShell, overlays.default); CI provisions from it; nix-direnv onboarding.
  • Track 2 — Nix image: dockerTools.buildLayeredImage, bit-reproducible, multi-arch (amd64+arm64); the project Python toolchain (vig-utils, ruff, bandit, pre-commit, pip-licenses) is baked from Nix; full testinfra suite passes (63/63).
  • Track 3 — CVE/audit: vulnix (nixpkgs-native) is the primary nightly scanner via vulnix-gate + .vulnixignore; Trivy stays for a CycloneDX SBOM.
  • Track 4 — Cutover & rollout: nixos-26.05 baseline; downstream minimal flake stub + nix2container pattern; install/init --mode=devcontainer|direnv|both picker; Debian path decommissioned (Containerfile, scripts/build.sh, hadolint, type=gha, Debian Trivy job, renovate dockerfile manager all removed).

Acceptance criteria

Publish / rollback

  • The published :latest flips to Nix at the next release from dev (release.yml, now Nix-only). Rollback, if ever needed, is to branch from the last Debian-built tagged release.

Closes #625. Supersedes #27, #255, #545, #602, #521, #604, #144, #153, #231.

c-vigo and others added 30 commits June 23, 2026 09:22
Add a guard that fails until agent rules/skills migrate from .cursor/ to
.claude/ and the root .cursor/ directory is deleted. Archival snapshots
(docs/issues, docs/pull-requests, docs/plans) and the downstream workspace
template (assets/workspace/, #629) are excluded.

Refs: #626
Replace the cursor-agent invocations in the worktree recipes with the
claude CLI. worktree-start/worktree-attach now launch
'claude --dangerously-skip-permissions' in the tmux session, mapping
'agent chat --yolo --approve-mcps' (autonomous, auto-approve shell and
MCP prompts) onto claude's permission-bypass flag. The cursor-specific
directory-trust helper and the 'tmux send-keys a' approval trigger are no
longer needed and are removed. Prerequisite, auth (claude auth
status/login, ANTHROPIC_API_KEY), requirements.yaml, and the generated
docs now reference the claude CLI.

Refs: #627
Replace Debian/FHS-specific assertions in tests/test_image.py with
path-agnostic equivalents so the suite is valid against both the current
Debian image and the future Nix image:

- convert dpkg host.package(...).is_installed checks (git, curl,
  openssh-client, nano, tmux, rsync) to --version/-V runs
- resolve gh, just, hadolint, taplo and cargo-installed tools via PATH
  (command -v) instead of hardcoded /usr/local/bin, /root/.cargo/bin and
  /root/.local/bin locations
- drop the DEBIAN_FRONTEND env assertion and apt-sourced version-prefix
  checks (git, curl, tmux, rsync) from EXPECTED_VERSIONS

Refs: #635
Move agent rules and skills from .cursor/ to .claude/ and delete the root
.cursor/ directory:

- Move the 30 skills to .claude/skills/ and rewrite the 29 .claude/commands/*.md
  wrappers to point at the new paths and .claude-located rules.
- Split the 7 .cursor/rules/*.mdc: static principles (coding-principles,
  commit-messages, changelog, single-source-of-truth) are consolidated into
  CLAUDE.md; workflow rules (branch-naming, tdd, subagent-delegation) become
  on-demand .claude/skills/.
- Port agent-models.toml and worktrees.json to .claude/.
- Update path consumers: docs/generate.py scan path, the check-skill-names and
  generate-docs pre-commit hooks, the check-skill-names and derive-branch-summary
  shell entrypoints, scripts/manifest.toml, docs/SKILL_PIPELINE.md(.j2),
  docs/RELEASE_CYCLE.md, CLAUDE.md command table, CODEOWNERS, label-taxonomy.toml,
  and the vig-utils README/tests.

The downstream assets/workspace/.cursor/ template is left for #629.

Refs: #626
## Summary

Implements C1 (#626): make `.claude/` the single source of truth for
agent rules and skills, removing the `.cursor/` indirection.

- **Skills:** moved the 30 `.cursor/skills/*/SKILL.md` (and the
`inception_explore/README.md` sibling) to `.claude/skills/`; rewrote the
29 `.claude/commands/*.md` wrappers to point at `.claude/skills/...` and
`.claude/`-located rules.
- **Rules split** (7 `.mdc`): static principles (`coding-principles`,
`commit-messages`, `changelog`, `single-source-of-truth`) consolidated
into `CLAUDE.md`; workflow rules (`branch-naming`, `tdd`,
`subagent-delegation`) became on-demand `.claude/skills/` with
`disable-model-invocation: true`.
- **Config:** ported `agent-models.toml` and `worktrees.json` to
`.claude/`.
- **Path consumers updated:** `docs/generate.py` scan path + docstrings;
`.pre-commit-config.yaml` (`check-skill-names` entry/`files`,
`generate-docs` `files` now also fires on `.claude/skills/**/SKILL.md` —
folds in #144); `check-skill-names.sh` and `derive-branch-summary.sh`
defaults; `scripts/manifest.toml`; `docs/SKILL_PIPELINE.md(.j2)`;
`docs/RELEASE_CYCLE.md`; `CLAUDE.md` command table + rule pointers;
`.github/CODEOWNERS`; `.github/label-taxonomy.toml`; vig-utils
`README.md` and tests.
- **Deleted** the root `.cursor/`.

## TDD

- `packages/vig-utils/tests/test_claude_ssot.py` asserts no tracked file
(outside `assets/workspace/`, archival
`docs/issues|pull-requests|plans/`, and append-only `CHANGELOG.md`)
references `.cursor/skills/`, and that root `.cursor/` is gone.
Committed failing first (RED), then implementation (GREEN).

## Verification

- All 492 vig-utils tests pass.
- `pre-commit run --all-files` green.
- `just docs` regenerates cleanly; command table intact (33 rows); the 3
new workflow skills do not leak into the table.
- Every command wrapper resolves to an existing
`.claude/skills/.../SKILL.md`; no `.cursor/` path in any wrapper; root
`.cursor/` deleted.

## Coordination notes

- The downstream `assets/workspace/.cursor/` template is **#629 (C4)'s**
scope and is intentionally left in place. The mandatory `sync-manifest`
pre-commit hook now also propagates `.claude/skills/` +
`.claude/worktrees.json` into `assets/workspace/.claude/`; the stale
`assets/workspace/.cursor/` tree is for #629 to remove.
- `justfile.worktree` recipe bodies were not edited (C2/W2). The
`~/.cursor/cli-config.json` reference in `SKILL_PIPELINE.md` is the
cursor-agent CLI runtime (#627) and was left untouched.
- #144 is effectively resolved by the updated `generate-docs` hook
filter.

Refs: #626
…' into feature/627-worktree-claude-cli

# Conflicts:
#	CHANGELOG.md
#	assets/workspace/.devcontainer/CHANGELOG.md
…' into feature/635-portable-testinfra

# Conflicts:
#	CHANGELOG.md
#	assets/workspace/.devcontainer/CHANGELOG.md
## Summary

Implements C2 (#627): replaces the `cursor-agent` CLI with the `claude`
CLI throughout the worktree pipelines. This is a functional change — the
recipes now drive `claude` end-to-end.

### Changes
- **`justfile.worktree`** (+ synced template
`assets/workspace/.devcontainer/justfile.worktree`, regenerated via
`sync-manifest`):
- Prereq check `command -v agent` → `command -v claude` (install hint →
`npm install -g @anthropic-ai/claude-code`).
- Auth block `agent status`/`agent login`/`CURSOR_API_KEY` → `claude
auth status`/`claude auth login`/`ANTHROPIC_API_KEY`.
- tmux launches `agent chat --yolo --approve-mcps "$PROMPT"` (and
no-prompt `agent chat --approve-mcps`) → `claude
--dangerously-skip-permissions "$PROMPT"` / `claude
--dangerously-skip-permissions`, in all three sites (existing-worktree,
new-worktree, attach-restart).
- Removed the cursor-specific `_wt_ensure_trust` helper + calls and the
`tmux send-keys "a"` approval trigger (no longer needed — see
decisions).
- **`scripts/requirements.yaml`**: `agent` (cursor.com installer) entry
→ `claude` entry (`command -v claude`, `claude --version`, `npm install
-g @anthropic-ai/claude-code`).
- **Docs**: `README.md` / `CONTRIBUTE.md` recipe help regenerated;
`docs/templates/SKILL_PIPELINE.md.j2` (and generated
`docs/SKILL_PIPELINE.md`) updated to describe the `claude` runtime.
- **`tests/bats/worktree-claude-cli.bats`**: lightweight recipe-grep
tests (no `cursor-agent`/`agent chat` survives; `claude` is driven;
prereq checks `claude`). The full `worktree.bats` functional rewrite is
C5's (#630) — not done here.
- **`CHANGELOG.md`** Unreleased / Changed entry.

### Decisions (flag-mapping)
- `--yolo` (auto-approve shell commands) **and** `--approve-mcps`
(auto-approve MCP servers) both map onto **`claude
--dangerously-skip-permissions`**, which bypasses all permission and MCP
approval prompts — the autonomous, no-human-at-terminal equivalent.
- **tmux pattern kept**: the detached `tmux new-session` driving is
retained (so `worktree-attach`, list/stop/clean lifecycle and
idle/dashboard features still work). The prompt is now passed as a
positional arg to `claude` at launch instead of via the interactive
session, so the post-launch `send-keys "a"` accept-trigger (which
dismissed cursor-agent's trust prompt) is **removed** —
`--dangerously-skip-permissions` means there is no prompt to accept.
- **Trust helper removed**: `_wt_ensure_trust` wrote
`~/.cursor/cli-config.json` `trustedDirectories`, which is
cursor-agent-only and unread by `claude`; with
`--dangerously-skip-permissions` directory trust is moot, so it is dead
code and was dropped. `jq` requirement stays (still used for
issue-metadata parsing).
- Did **not** touch `_read_model`/`.cursor/agent-models.toml` or
`.cursor/rules` paths (W1/C1's domain).

### Verification
- `grep -n 'cursor-agent\|agent chat' justfile.worktree
assets/workspace/.devcontainer/justfile.worktree` → empty.
- `scripts/requirements.yaml` lists `claude`, not `agent`.
- `just precommit` → all hooks green (including `sync-manifest`,
`generate-docs`, `shellcheck`, `just` format).
- `just --list` parses the worktree recipes; help text shows "launch the
claude CLI".

Refs: #627
…' into feature/635-portable-testinfra

# Conflicts:
#	CHANGELOG.md
#	assets/workspace/.devcontainer/CHANGELOG.md
…es (#645)

## Summary

Makes `tests/test_image.py` path-agnostic (T2.2) so the testinfra suite
stays valid against **both** the current Debian image and the future Nix
image. Pure test refactor — no `conftest.py` image source/name changes.

- Convert dpkg `host.package(...).is_installed` checks (`git`, `curl`,
`openssh-client`, `nano`, `tmux`, `rsync`) to `--version`/`-V` runs via
a small `assert_tool_runs` helper.
- Resolve `gh`, `just`, `hadolint`, `taplo` and cargo-installed tools
via PATH (`command -v`, new `assert_tool_on_path` helper) instead of
hardcoded `/usr/local/bin`, `/root/.cargo/bin`, `/root/.local/bin`.
- Drop the `DEBIAN_FRONTEND` env assertion and the apt-sourced
version-prefix checks (`git`, `curl`, `tmux`, `rsync`) from
`EXPECTED_VERSIONS`.
- Reviewed the `/root/assets/workspace/` mount assertions: they use
POSIX `host.file(...)` checks on image-controlled `/root/...` paths (not
Debian-FHS/dpkg), so they are already portable and left functionally
intact.

Note: bumped the manually-installed `just` version pin `1.53.` ->
`1.54.` to match the current latest-release binary in the image (the old
pin was already failing on a fresh build, independent of this refactor).

## Verification

- Built the Debian image with isolated tag `wt635` (`./scripts/build.sh
wt635 ...`), no `:latest`/`:dev` clobber, nothing pushed.
- `just test-image wt635`: **64 passed in 193.32s** (green).
- `just precommit`: all hooks pass.
- No Debian/FHS-only assertions remain (no
`is_installed`/`host.package`, no hardcoded `/usr/local/bin` etc., no
`DEBIAN_FRONTEND`, no apt version prefixes) — remaining textual matches
are docstrings/comments only.

Refs: #635
Delete the unpinned cursor.com/install build step and its
/root/.local/bin PATH entry, leaving an all-nixpkgs toolchain
ahead of the Nix migration.

Refs: #628
Remove test_cursor_agent_installed, which asserts a feature removed
in the same change; keeps the suite coherent and green.

Refs: #628
Remove the CVE-2026-55388 (piscina) .trivyignore entry, which only
existed for the now-removed cursor-agent CLI.

Refs: #628
Replace the stale assets/workspace/.cursor/ template tree with the
Claude-native .claude/ payload (skills, agent-models.toml, worktrees.json)
via the sync manifest, and drop Cursor editor glue:

- Remove assets/workspace/.cursor/; add .claude/agent-models.toml to the
  workspace sync manifest so the template carries the same payload the old
  .cursor/ template did.
- Drop the cursor-remote-ssh socket glob from verify-auth.sh.
- Drop the command -v cursor fallback in justfile.devc open recipe (VS Code only).
- COMMIT_MESSAGE_STANDARD.md: VS Code / Cursor -> VS Code.

Refs: #629
## C4 #629 — Migrate templates & editor glue off Cursor (VS Code only)

Reconciles the workspace template left behind by C1 (#626) and removes
the
remaining Cursor **editor** glue in #629's scope. Decision: **VS Code
only**.

### Changes
- **Template `.cursor/` → `.claude/`**: deleted the stale
`assets/workspace/.cursor/`
  tree. C1's sync-manifest hook already propagated `.claude/skills/` and
  `.claude/worktrees.json`; this PR adds `.claude/agent-models.toml` to
`scripts/manifest.toml` so the template carries the **same payload the
old
`.cursor/` template did** (skills + agent-models.toml + worktrees.json).
The
template's synced `subagent-delegation` skill references
`agent-models.toml`,
  so this also fixes a dangling reference C1 left.
- **`verify-auth.sh`**: dropped the `/tmp/cursor-remote-ssh-*.sock` glob
from the
  SSH-agent socket scan.
- **`justfile.devc`**: `open` recipe now launches VS Code only (removed
the
  `command -v cursor` fallback).
- **`docs/COMMIT_MESSAGE_STANDARD.md`** (root SSoT + synced template
copy):
  "VS Code / Cursor" → "VS Code".
- **CHANGELOG**: added an `## Unreleased` Changed entry.

### Scope boundaries (intentionally NOT touched)
- `justfile.worktree` and `solve-and-pr/SKILL.md` `cursor-agent`
references →
owned by **#627** (worktree pipelines cursor-agent → claude); they sync
from
  the root SSoT, so editing them here would collide with #627.
- `.github/agent-blocklist.toml` "cursor" entries → AI commit-identity
blocklist,
  explicitly **kept** per **#630**. Not editor glue.
- `setup-git-conf.sh` had no remaining `cursor` references on the epic
tip.

### TDD
- RED: `tests/bats/init-workspace.bats` — assert template scaffolds
`.claude/`
(+ `skills/`), does NOT scaffold `.cursor/`, and carries no
`cursor-remote` /
  `command -v cursor` glue. Committed failing first.
- GREEN: implementation makes all four pass; full `init-workspace.bats`
suite
  27/27 green.
- Build-free rationale: the integration `initialized_workspace` fixture
requires
a container image build (out of bounds this batch). `init-workspace.sh`
rsyncs
`assets/workspace/` verbatim, so structural assertions on the template
tree are
  a faithful proxy for what new workspaces scaffold.

### Verification
- `just precommit` — all hooks pass (sync-manifest, check-skill-names,
  generate-docs, shellcheck, taplo, etc.).
- `grep -rn 'cursor' assets/workspace justfile.devc` (excluding
CHANGELOG prose)
  returns only the out-of-scope #627/#630 hits listed above.
- No `assets/workspace/.cursor/` remains.

Refs: #629
Removes the unpinned `cursor-agent` CLI install from the devcontainer
image and the CVE ignore tied to it.

- Delete the cursor-agent install block from `Containerfile` and
`build/Containerfile` (+ dead PATH note)
- Drop `CVE-2026-55388` (piscina, bundled in cursor-agent) from
`.trivyignore`
- Remove the now-invalid `test_cursor_agent_installed` image test
(atomically coupled to the install removal; pre-empts that one bullet of
C5 #630)
- Changelog entry

Verification deferred to CI (image suite): build + image tests + Trivy
security scan. Local build was not completed in-worktree.

Refs: #628
…650)

## Summary

Implements C5 (#630) of the Nix/Claude migration epic (#625): adapts the
Cursor-coupled worktree test to the `claude` CLI and locks in the
AI-identity blocklist so removing Cursor never weakens the org "never
name an AI in history" control.

- **`tests/bats/worktree.bats`** — replaced the `send-keys 'a' approves
agent trust prompt` case (which skipped on `command -v agent` and
asserted `"Cursor Agent"`) with `claude CLI launches in tmux without an
interactive trust prompt`: skips on `command -v claude`, launches
`claude --dangerously-skip-permissions --version` the way the migrated
recipes do, and asserts no trust prompt stalls the pane. No assertion
expecting `cursor-agent`/"Cursor Agent" remains.
- **Blocklist regression test** — added `TestCanonicalBlocklist` in
`packages/vig-utils/tests/test_check_pr_agent_fingerprints.py`
exercising the REAL `.github/agent-blocklist.toml` (not a mock): a
parametrized test proves BOTH `cursor` and `claude` are rejected in a
commit message, plus a guard that both names stay in the blocklist. The
`cursor` entries are intentionally kept.
- **CHANGELOG** — `## Unreleased` Changed entry for #630.

## Reconciliation with C2 (#627)
C2 added `tests/bats/worktree-claude-cli.bats` (static recipe-grep:
justfiles drive `claude`, no `cursor-agent` survives). That file is kept
as-is — it covers the recipe layer. This PR covers the complementary
runtime/behavioral layer in `worktree.bats`, so there is no duplication.
C3 (#628) already removed `test_cursor_agent_installed`; not touched.

## Verification
- `just precommit` — all hooks pass.
- `uv run pytest` (vig-utils) — 495 passed (incl. 3 new blocklist
tests).
- `CI=true npx bats tests/bats/worktree.bats
tests/bats/worktree-claude-cli.bats` — 13 passed (tmux integration cases
skip under CI by design).
- Blocklist test confirmed proving cursor + claude both rejected against
the canonical TOML.

Done via TDD: blocklist regression test committed first (passes against
current blocklist as a guard), then the bats rewrite, then the
changelog.

Refs: #630
Factor a single devTools list as the source of truth shared by the dev-shell
now and the image later. Absorb the #545 agent-CLI toolkit (rg, fd, bat, eza,
delta, lazygit, zoxide, starship, freeze, expect, neovim, claude). Pin nixpkgs
to nixos-25.05 and overlay fast-movers (uv, gh, claude-code) from
nixpkgs-unstable. Add reusable lib.mkProjectShell, overlays.default and a
packages.devcontainerImage stub, and refresh flake.lock.

Refs: #631
Build the flake dev-shell and push its closure to the vig-os Cachix cache via
cachix/install-nix-action (upstream CppNix) + cachix/cachix-action. The job is
a standalone, non-required workflow with continue-on-error so it never affects
the existing CI gate, and adds workflow_dispatch for on-branch validation.

Refs: #631
## Summary

Implements **T1.1 (#631)** — collapses the flake dev-shell and the
(future) image tool list into a single `devTools` source of truth and
lays the reusable flake outputs the rest of the Nix track depends on.

### What changed
- **Single `devTools` SSoT** in `flake.nix` — shared basis for the
dev-shell now and the image later (#634). Absorbs the #545 agent-CLI
toolkit (`rg`, `fd`, `bat`, `eza`, `delta`, `lazygit`, `zoxide`,
`starship`, `freeze`, `expect`, `nvim`) plus `claude`, on top of the
existing toolchain (`just`, `git`, `gh`, `uv`, `nodejs`, `jq`, `tmux`,
`shellcheck`, `hadolint`, `taplo`, `podman`).
- **Channel switch** — `nixpkgs` pinned to `nixos-25.05`; new
`nixpkgs-unstable` input overlaid **only** for fast-movers `uv`, `gh`,
`claude-code`. `flake.lock` refreshed.
- **Reusable outputs** — `lib.mkProjectShell` (downstream repos build
the shared shell + extra packages), `overlays.default` (the fast-mover
overlay), and a `packages.devcontainerImage` **stub** (placeholder; real
image is T2.1/#634).
- **Cachix CI** — new non-blocking `nix-cachix.yml` workflow that builds
the dev-shell and pushes its closure to the `vig-os` cache.
- **TDD parity test** — `tests/test_flake_devshell.py` reads the binary
names straight from the flake (`nix eval .#devShellTools.<system>`) and
runs `nix develop -c <tool> <version-flag>` for each, so it can never
drift from `devTools`.

### How `claude` is provided
`claude` is the **`claude-code`** package from nixpkgs (binary `claude`,
via `meta.mainProgram`). It is a fast-mover, so it is sourced from
`nixpkgs-unstable` through `overlays.default` (unstable `2.1.x` vs
stable `1.0.85`). No custom fetch/overlay-from-scratch needed — it is
packaged cleanly in nixpkgs.

> Note: nixpkgs `freeze` is an EDR-bypass payload toolkit
(optiv/Freeze), **not** the charmbracelet terminal-screenshot tool #545
intended. Used **`charm-freeze`** (binary `freeze`) instead.

### Evaluator choice + rationale
**Upstream CppNix** via `cachix/install-nix-action` (SHA-pinned
`v31.10.6`). It is the most broadly supported installer, pairs natively
with `cachix/cachix-action`, and needs no extra infra. The flake is
**installer-agnostic** — swapping to the Lix `lix-installer` or the
Determinate installer requires **zero flake changes**, so this decision
is cheaply reversible.

### Cachix wiring
- `cachix/install-nix-action@…v31.10.6` (flakes enabled) →
`cachix/cachix-action@…v17` with `name: ${{ vars.CACHIX_CACHE }}` and
`authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}` (token never
printed/hardcoded).
- Builds `nix develop --profile dev-profile` and pushes `nix path-info
-r ./dev-profile | cachix push`.
- **Non-blocking:** standalone workflow (not a required check) +
`continue-on-error: true` → existing CI is unaffected.
- Adds **`workflow_dispatch`** plus a push trigger on the epic branch
for on-branch validation.
- Actions are SHA-pinned per `check-action-pins` policy.

### Verification (local, against the public `vig-os` cache)
- `nix flake check` → **all checks passed** (`devShellTools` shows only
a benign "unknown flake output" warning).
- `tests/test_flake_devshell.py` → **2 passed** — every one of the 23
tools runs inside `nix develop` (incl. `claude --version`, `nvim
--version`, `rg --version`, `freeze --version`, `tmux -V`, `expect -v`).
- `just precommit` → **all hooks pass** (incl. `check-action-pins`,
agent-identity, yamllint).

### TDD trail
- `test(nix):` failing parity test committed first (RED).
- `feat(nix):` flake SSoT + outputs making it pass (GREEN).
- `ci(nix):` non-blocking Cachix workflow.

Refs: #631
Switch .envrc from bare `use flake` to nix-direnv so the dev-shell
evaluation is GC-rooted and cached under .direnv/, making re-entry
instant and protecting the closure from garbage collection. nix-direnv
self-bootstraps on first `direnv allow` and falls back to bare
`use flake` when unavailable.

Document the clone -> `direnv allow` onboarding flow, the vig-os Cachix
substituter (binary fetch instead of from-source build on first allow),
and enabling the nix-command/flakes experimental features in the
CONTRIBUTE.md.j2 source template; regenerate with `just docs`. Folds in
the Nix-flake-as-alternative-dev-setup documentation request.

Refs: #633
c-vigo and others added 9 commits June 24, 2026 20:42
The integration suite scaffolds from the freshly-built image but
`devcontainer up` resolves the runtime image from DEVCONTAINER_VERSION,
so it validates fresh scaffolding inside the stale published image. Add
a regression test asserting the running devcontainer uses the image
under test (TEST_CONTAINER_TAG).

Refs: #701
The devcontainer_up/devcontainer_with_sidecar fixtures scaffolded a
workspace from the freshly-built image but let `devcontainer up` resolve
the runtime image from DEVCONTAINER_VERSION (the published release baked
into the scaffolded .vig-os/.env), so the suite validated fresh
scaffolding inside a stale image. Export DEVCONTAINER_VERSION =
TEST_CONTAINER_TAG; compose reads the shell environment over .env, so the
scaffolded docker-compose.yml resolves the build under test, and the
in-test `devcontainer exec` calls inherit it.

Refs: #701
post-create.sh ran an unguarded `sed` on the venv activate script before
`just sync`. The Debian image baked that venv at build time, but the Nix
image populates /root/assets/workspace/.venv during post-create, so the
script did not exist yet and post-create aborted (exit 2), failing
`devcontainer up`. Move the prompt rewrite after `just sync` and guard it
on the file's existence. uv writes the prompt as the venv parent's
basename, so rewrite the VIRTUAL_ENV_PROMPT assignment directly instead
of substituting the no-longer-present "template-project" literal.

Refs: #701
The #697 decoupling shipped the scaffolded ruff/ruff-format/typos hooks
as self-contained upstream (manylinux) hooks because the integration
suite still ran the published Debian image, whose PATH lacked those
tools. Now that the suite runs the freshly-built Nix image -- whose
non-FHS userland cannot execute those manylinux binaries -- the workaround
breaks the in-container `git commit`. Restore the repo's repo-local
language:system hooks in the scaffold (resolved from the image's baked
devTools), and drop the now-unused ReplacePrecommitRepoBlock transform
and its tests.

Refs: #701
Explain in tests/README.md how TEST_CONTAINER_TAG selects the image under
test and why the devcontainer fixtures override DEVCONTAINER_VERSION so
compose uses it. Record the fixes under CHANGELOG Unreleased.

Refs: #701
…702)

## Description

The integration suite scaffolded a workspace from the freshly-built
image (`TEST_CONTAINER_TAG`) but then ran `devcontainer up` from
whatever `DEVCONTAINER_VERSION` resolved to — the published `0.3.9`. So
it validated *fresh scaffolding inside a stale image*, not the image
under test. Pointing it at the fresh Nix image then surfaced real
bring-up failures. This fixes all of it. Closes the gap tracked in #701.

## Type of Change

- [x] `fix` -- Bug fix

## Changes Made

- **Run the image under test.**
`devcontainer_up`/`devcontainer_with_sidecar` now export
`DEVCONTAINER_VERSION=TEST_CONTAINER_TAG`. Compose resolves shell env
over `.env`, so the scaffolded `docker-compose.yml` (`image:
…:${DEVCONTAINER_VERSION:-latest}`) selects the build under test, and
the in-test `devcontainer exec` calls inherit it. Added
`test_devcontainer_runs_image_under_test` asserting the running
container's image.
- **Bring the Nix image up cleanly.** `post-create.sh` ran an unguarded
`sed` on the venv `activate` script *before* `just sync`. The Debian
image baked that venv at build time; the Nix image creates it during
post-create, so the script didn't exist yet and post-create aborted
(exit 2). Moved the prompt rewrite after `just sync`, guarded it, and
rewrote the `VIRTUAL_ENV_PROMPT` assignment directly (uv sets the prompt
to the venv parent's basename, so the old `template-project` literal is
never present).
- **Scaffold ruff/ruff-format/typos as `language: system`.** Reverted
the #697 decoupling: those scaffolded hooks pulled upstream manylinux
binaries, which the non-FHS Nix userland cannot execute (`No such file
or directory` on the ELF interpreter), breaking the in-container `git
commit`. They now resolve from the image's baked `devTools`, like the
repo's own config. Removed the now-unused `ReplacePrecommitRepoBlock`
sync-manifest transform and its tests.
- **Docs.** Documented the image-selection behaviour in
`tests/README.md`.

## Changelog Entry

### Fixed
- **Integration tests now exercise the freshly-built image, not the
published `DEVCONTAINER_VERSION`**
([#701](#701))
- The fixtures export `DEVCONTAINER_VERSION=TEST_CONTAINER_TAG` so
compose resolves the build under test; added
`test_devcontainer_runs_image_under_test`
- Guarded/moved the `post-create.sh` venv-prompt `sed` so the Nix image
comes up cleanly under `devcontainer up`
- Reverted the [#697](#697)
scaffold decoupling: scaffolded `ruff`/`ruff-format`/`typos` are
`language: system` again; removed the unused `ReplacePrecommitRepoBlock`
transform

## Testing

- [x] Tests pass locally (`just test`)
- [x] Manual testing performed (describe below)

### Manual Testing Details

Run against the freshly-built Nix image (`TEST_CONTAINER_TAG=dev`,
podman socket + standalone `docker-compose` as CI provides):

- `tests/test_integration.py`: **123 passed, 7 skipped** (skips are
host-environmental: SSH signing config + sidecar compose), 0 failed
- `tests/test_image.py`: **64 passed**
- `tests/test_transforms.py` + `tests/test_flake_devshell.py`: pass
- `test_devcontainer_runs_image_under_test` confirmed RED against
`0.3.9` → GREEN against `dev`
- `test_pre_commit_hook` exercises the in-container ruff+typos run that
previously failed on the Nix image — now passes.
`test_git_commit_ssh_signature` skips locally (no host SSH signing
config) but runs in CI's test-integration action.

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have updated the documentation accordingly
- [x] I have updated `CHANGELOG.md` in the `[Unreleased]` section (and
pasted the entry above)
- [x] My changes generate no new warnings or errors
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes

## Additional Notes

Sub-issue of the Nix migration epic #625; targets
`feature/625-nix-claude-migration`. The `install.sh` host-side
`/etc/os-release` probe mentioned in the issue is not exercised by
`devcontainer up` (it has a `linux` fallback) and is left unchanged.

Refs: #701
The #698 LD_LIBRARY_PATH libstdc++ injection is only needed and ABI-safe on NixOS. Gate the two injection-presence tests to NixOS and add an FHS-only guard asserting the Nix C++ runtime is not exposed on LD_LIBRARY_PATH (it breaks host binaries with GLIBC_ABI_DT_X86_64_PLT). Fails until the dev-shell gate lands.

Refs: #703
…ixOS

The #698 dev-shell exported the Nix C++ runtime (stdenv.cc.cc.lib, linked against glibc 2.42) onto LD_LIBRARY_PATH unconditionally. On an FHS host with an older system glibc (Ubuntu 24.04 = 2.39) that libstdc++ leaks into host binaries — every just recipe's '#!/usr/bin/env bash', and anything an /etc/ld.so.preload agent pulls libstdc++ into — dragging in the Nix libm.so.6 and aborting with 'GLIBC_ABI_DT_X86_64_PLT not found'. Inject only on NixOS ([ -e /etc/NIXOS ]), where it is both required and ABI-safe; FHS hosts resolve libstdc++ from the system loader, so the injection is a no-op and nothing leaks.

Refs: #703
…ixOS (#704)

## Description

Closes #703. Fixes a regression from #698 that broke **every `just`
recipe inside
`nix develop` on non-NixOS (FHS) hosts** with:

```
/usr/bin/env: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_ABI_DT_X86_64_PLT' not found
   (required by /nix/store/…-glibc-2.42-67/lib/libm.so.6)
```

Root cause: #698 exported `${stdenv.cc.cc.lib}/lib` (the Nix C++
runtime, linked
against **glibc 2.42**) onto the dev-shell `LD_LIBRARY_PATH`
unconditionally — so
the pymarkdown pre-commit wheel could resolve `libstdc++.so.6`. On an
FHS host
whose **system glibc is older** (Ubuntu 24.04 = 2.39), that Nix
`libstdc++` is
pulled into host binaries — every `just` recipe runs via `#!/usr/bin/env
bash`,
and on hosts with an `/etc/ld.so.preload` agent that links `libstdc++`
it reaches
`/usr/bin/env` directly — dragging in the Nix `libm.so.6`, which
requires the
`GLIBC_ABI_DT_X86_64_PLT` symbol version the host's 2.39 `libc` does not
export.
It worked on NixOS only because there the system glibc *is* the Nix
glibc.

## Type of Change

- [x] `fix` -- Bug fix

## Changes Made

- `flake.nix` (`mkProjectShell`): inject the Nix C++ runtime onto
`LD_LIBRARY_PATH`
**only on NixOS** (`[ -e /etc/NIXOS ]`), where it is both *required*
(libstdc++
is off the default loader path) and *ABI-safe* (system glibc is the Nix
glibc).
On FHS hosts the system loader already resolves `libstdc++`, so the
injection is
  a no-op and nothing leaks into host binaries.
- `tests/test_flake_devshell.py`: the #698 injection-presence tests are
gated to
NixOS, and a new FHS-only
`test_devshell_no_nix_cxx_runtime_leak_on_fhs_host`
asserts the Nix C++ runtime is **not** exposed on `LD_LIBRARY_PATH` (the
RED
  anchor for this fix).
- `CHANGELOG.md` (+ synced workspace copy): `### Fixed` entry.

## Changelog Entry

```
### Fixed

- **Nix dev-shell no longer breaks `just` on non-NixOS hosts (Nix C++ runtime leaked onto `LD_LIBRARY_PATH`)** ([#703](#703))
  - The #698 fix exported `${stdenv.cc.cc.lib}/lib` (the Nix C++ runtime, linked against glibc 2.42) onto the dev-shell `LD_LIBRARY_PATH` unconditionally. On an FHS host whose system glibc is older (e.g. Ubuntu 24.04 ships 2.39), that `libstdc++` is pulled into host binaries … aborting with `version 'GLIBC_ABI_DT_X86_64_PLT' not found`, so every `just` recipe failed inside `nix develop`
  - `mkProjectShell` now injects the Nix C++ runtime onto `LD_LIBRARY_PATH` only on NixOS (`[ -e /etc/NIXOS ]`) … FHS hosts resolve `libstdc++` from the system loader, so the injection is a no-op there and nothing leaks. The #698 dev-shell parity tests are gated to NixOS and an FHS leak-guard was added
```

## Testing

- [x] Tests pass locally — `pytest tests/test_flake_devshell.py`: **8
passed, 2
skipped** (the two NixOS-only injection tests skip on this FHS host; the
  leak-guard passes).
- [x] Manual testing performed (below)

### Manual Testing Details

On Ubuntu 24.04 (glibc 2.39), inside `nix develop`:

- Before: `LD_LIBRARY_PATH` carried `…/gcc-15.2.0-lib/lib`; `just build`
/
  `just test-bats` aborted with the GLIBC error above.
- After: `LD_LIBRARY_PATH` is empty; `/usr/bin/env bash` runs; `just
lint` passes
  and `just build` proceeds into the real Nix image build.
- TDD: the leak-guard test was committed RED first (`a818fb7`), then the
flake
  gate made it GREEN (`e27962a`).

> CI does not auto-run for PRs into the epic branch (`ci.yml` triggers
on PRs to
> `dev`/`release/**`/`main`), so the `project` suite is dispatched
manually on
> this head branch.

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have updated `CHANGELOG.md` in the `[Unreleased]` section (and
pasted the entry above)
- [x] My changes generate no new warnings or errors
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
c-vigo and others added 15 commits June 25, 2026 14:24
The buildLayeredImage tag was a stale, branch-specific WIP value
(nix-wt634). Align it with the disposable discovery tag the CI
workflow documents as INDEX_TAG=nix-dev
(.github/workflows/nix-image.yml). The versioned / :latest cutover
tag is out of scope (#639).

Skip TDD: build constant, no behavioral surface.

Refs: #705
Remove the repeated "Cleaning" from the clean-test-containers echo so it
reads "Cleaning up lingering test containers...".

Skip TDD: cosmetic/comment-only, no behavioral surface.
Skip CHANGELOG: no user-visible impact (echo wording only).

Refs: #710
The Nix image ships CPython 3.14, but the TESTING template still listed
Python 3.12. Update the template and regenerate TESTING.md.

Skip TDD: documentation template.
Skip CHANGELOG: minor doc accuracy fix, no user-visible behaviour change.

Refs: #709
The hook id sync-manifest was defined twice. The second block only ran
when scripts/manifest.toml changed, which would miss drift in other
synced source files. Keep the broad always-run hook and remove the
narrow duplicate.

Skip TDD: pre-commit config de-duplication, no behavioral surface.

Refs: #707
The .vscode/settings.json manifest entry rewrote the interpreter path to
/opt/venv/bin/python3, the decommissioned Debian image path. The Nix image
has no /opt/venv, so downstream projects got a broken VS Code interpreter.
Remove the Sed transform so the workspace-relative
${workspaceFolder}/.venv/bin/python3 (resolved against the opened project,
where uv creates .venv) passes through unchanged, and regenerate the synced
workspace asset.

Refs: #706
The added TestWorkspaceInterpreterPath was committed via an env workaround
that bypassed ruff-format; CI's flake-sourced ruff (0.15.x) flags it.

Refs: #706
Align ruff with requires-python >=3.14. The flake-sourced ruff-format
(0.15.x) then applies PEP 758 to the py314 target, dropping the
parentheses on multi-exception except clauses in tests/conftest.py and
packages/vig-utils/tests/test_claude_ssot.py.

Skip TDD: ruff lint/format configuration.

Refs: #708
## Summary

`flake.nix` baked a stale, branch/issue-specific WIP tag `nix-wt634`
into `dockerTools.buildLayeredImage`. This aligns the image tag with the
disposable discovery tag the CI workflow already documents as
`INDEX_TAG: nix-dev` (`.github/workflows/nix-image.yml`), so the flake
and CI agree on one tag.

- Changed `tag = "nix-wt634"` -> `tag = "nix-dev"`, with a clarifying
comment.
- Does **not** touch the versioned / `:latest` cutover tags, which are
tracked separately in #639.

## Verification

- `grep -rn 'nix-wt' . --exclude-dir=.git --exclude-dir=docs` returns
nothing.
- `nixfmt --check flake.nix` clean; `nix flake check --no-build` -> all
checks passed.

Skip TDD: build constant, no behavioral surface. No CHANGELOG entry:
internal build plumbing, not user-visible.

Closes #705
Purely cosmetic, non-functional cleanup for #710.

## Changes
- **justfile** (`clean-test-containers` recipe): removed the duplicated
word so the echo reads `Cleaning up lingering test containers...`
instead of `Cleaning Cleaning up ...`.

## Left alone (deliberately)
- `assets/workspace/.devcontainer/scripts/post-create.sh`: comments are
generic and not Debian-specific; nothing is factually wrong post-Nix.
- `tests/test_image.py`: the `EXPECTED_VERSIONS` block carries
Debian-era comments ("from apt package", "Containerfile", install-method
notes), but that whole block (values + comments) is being rewritten by
the active Nix-migration work, so touching the comment text here would
overlap with another PR. Per the "when in doubt, skip" guidance, left
untouched.

Skip TDD: cosmetic/comment-only, no behavioral surface.
Skip CHANGELOG: no user-visible impact (echo wording only).

Closes #710
The Nix image ships CPython 3.14, but the auto-generated TESTING.md (via
docs/templates/TESTING.md.j2) still listed Python 3.12. Fixed the
template and regenerated TESTING.md so the docs match the shipped
interpreter (README was already correct).

Closes #709
Kept the broad always-run sync-manifest hook (no `files:` filter, so it
runs whenever any synced source file changes). Removed the duplicate
`sync-manifest` block whose narrow `files: ^scripts/manifest\.toml$`
filter would miss drift in other synced sources (CHANGELOG.md,
.vscode/settings.json, .pre-commit-config.yaml, etc.).

Closes #707
Align ruff `target-version` with the project's `requires-python =
">=3.14,<3.15"` so lint and formatting target the real runtime instead
of the stale `py312` value.

Closes #708
…env (#716)

`/opt/venv` is the decommissioned Debian image path; the Nix image has
no such directory. The `.vscode/settings.json` manifest entry rewrote
`python.defaultInterpreterPath` to `/opt/venv/bin/python3`, shipping a
broken VS Code interpreter to downstream projects.

This removes the Sed transform so the source's workspace-relative
`${workspaceFolder}/.venv/bin/python3` passes through unchanged (VS Code
resolves `${workspaceFolder}` to the opened project, where `uv` creates
`.venv`), and regenerates the synced workspace asset.

Added a behavioral test that runs the real manifest sync and asserts the
interpreter path is workspace-relative and never `/opt/venv`.

Closes #706
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant